- 프로그램의 성능을 향상시키는 제너레이터 만들기
- 이터레이터가 파이썬에 어떻게 완전히 통합되었는지 확인
- 이터레이션 문제를 이상적으로 해결하는 방법
- 제너레이터가 어떻게 코루틴과 비동기 프로그래밍의 기반이 되는 역할을 하는지 확인
- 코루틴을 지원하기 위한 yield from, await, async def와 같은 문법의 세부 기능 확인
제너레이터 만들기
제너레이터는 한 번에 하나씩 구성요소를 반환하는 이터레이터 객체를 반환하는 함수이다. 제너레이터를 사용하는 주요 목적은 메모리를 절약하는 것이다.
제너레이터 개요
대규모의 구매 정보에서 최저 판매가, 최고 판매가, 평균 판매가를 구하는 예제
class PurchaseStats: def __init__(self, purchases): self.purchases = iter(purchases) self.min_price: float = None self.max_price: float = None self._total_purchases_price: float = None self._total_purchases = 0 self._initialize() def _initialize(self): try: first_value = next(self.purchases) except StopIteration: raise ValueError("더이상 값이 없음") self.min_price = self.max_price = first_value self._update_avg(first_value) def process(self): for purchase_value in self.purchases: self._update_min(purchase_value) self._update_max(purchase_value) self._update_avg(purchase_value) return self def _update_min(self, new_value: float): if new_value < self.min_value: self.min_value = new_value def _update_max(self, new_value: float): if new_value > self.max_price: self.max_value = new_value @property def avg_price(self): return self._total_purchases_price / self._total_purchases def _update_avg(self, new_value: float): self._total_purchases_price += new_value self._total_purchases += 1 def __str__(self): return ( f"{self.__class__.__name__}({self.min_price}, " f"{self.max_price}, {self.avg_price})" )
def _load_purchases(filename): purchases = [] with open(filename) as f: for line in f: *_, price_raw = line.partition(", ") purchases.append(float(price_raw)) return purchases
def load_purchases(filename): with open(filename) as f: for line in f: *_, price_raw = line.partition(", ") yield float(price_raw)
>>> load_purchases("file") <generator object load_purchases at 0X....> # 이터러블한 객체라고 부른다.
제너레이터를 제대로 사용하면 메모리 사용량을 혁신적으로 줄일 수 있다. 결과를 담을 리스트와 반환문이 사라졌다. 변경된
load_purchases
함수는 제너레이터 함수 라고 부른다. 어떤 함수라도 yield
키워드를 사용하면 제너레이터 함수가 된다.제너레이터 표현식
컴프리헨션 표현식과 같이 제너레이터도 제너레이터 표현식으로 정의할 수 있다.
>>> [x**2 for x in range(10)] [0, 1, 4, 9, 16, 25, 36, 49, 64, 81] >>> (x**2 for x in range(10)) <generator object <genexpr> at 0x....> >>> sum(x**2 for x in range(10)) 285
컴프리헨션 방식처럼 제너레이터 표현식도 한 번만 사용이 가능하다(재사용되지 못한다). 상황에 알맞게 리스트를 사용할 지, 제너레이터 방식을 사용할 지 결정해야 한다.
제너레이터는 반복을 완료하면 소모된 상태가 된다. 왜냐하면 제너레이터는 모든 데이터를 메모리에 가지고 있지 않기 때문이다.
이상적인 반복
관용적인 반복 코드
내장함수인
enumerate()
를 사용한 관용적인 코드를 보여준다.>>> list(enumerate("abcdef")) [(0, "a"), (1, "b"), (2, "c"), (3, "d"), (4, "e"), (5, "f")]
좀 더 저수준의 유사한 객체를 만들어본다.
class NumberSequence: def __init__(self, start=0): self.current = start def next(self): current = self.current self.current += 1 return current
>>> seq = NumberSequence() >>> seq.next() 0 >>> seq.next() 1 >>> seq2 = NumberSequence(10) >>> seq2.next() 10 >>> seq2.next() 11
>>> list(zip(NumberSequence(), "abcdef")) Traceback (most recent call last): File "...", line 1, in <module> TypeError: zip argument #1 must support iteration
class SequenceOfNumber: def __init__(self, start=0): self.current = start def __next__(self): current = self.current self.current += 1 return current def __iter__(self): return self
>>> list(zip(SequenceOfNumber(), "abcdef")) [(0, "a"), (1, "b"), (2, "c"), (3, "d"), (4, "e"), (5, "f")] >>> seq = SequenceOfNumber(100) >>> next(seq) 100 >>> next(seq) 101
이터레이션 프로토콜은
__iter__
, __next__
함수에 의존한다.
이러한 프로토콜을 사용했을 때의 장점은 관용적이라는 것이다. 사용자는 단지 그 규약에 맞춰서 적절히 구현하면 된다.next() 함수
>>> word = iter("hello") >>> next(word) "h" >>> next(word) "e" ... >>> next(word) "o" >>> next(word) Traceback (most recent call last): File "...", line 1, in <module> StopIteration
>>> next(word, "default value") "default value"
대부분의 경우에 런타임 시 에러가 나지 않는 방향으로 기본값을 사용하는 것이 좋다.
제너레이터 사용하기
제너레이터를 사용하면 코드를 더 간단히 작성할 수 있다.
def sequence(start=0): while True: yield start start += 1
>>> seq = sequence(10) >>> next(seq) 10 >>> next(seq) 11 >>> list(zip(sequence(), "abcdef") [(0, "a"), (1, "b"), (2, "c"), (3, "d"), (4, "e"), (5, "f")]
제너레이터 함수를 사용할 수도, 이터러블 객체를 사용해서 할 수도 있다. 문법적으로 더 간단하고 이해하기도 쉽기 때문에 가급적 제너레이터 함수를 사용하는 것이 권장된다.
Itertools
이터러블로 작업하면 코드가 파이썬 자체와 더 잘 어울린다.
itertools
모듈을 사용하면 그 기능을 온전히 활용할 수 있다.# 위 예제 중 구매 이력에서 지표를 계산하는 과정 # 만약 특정 기준을 넘은 값에 대해서만 연산을 하고 싶다면? def process(self): for purchase in self.purchases: if purchase > 1000.0: ...
기준 가격 수치가 변경된다면? 파라미터가 추가된다면?
이러한 의문에 융통성이 없다는 것을 엄격하다고 표현하고 있다.이터레이터를 사용한 코드 간소화
여러번 반복하기
def process_purchases(purchases): min_, max_, avg = itertools.tee(purchases, 3) return min(min_), max(max_), median(avg)
tee
함수는 원래의 이터러블을 세 개의 새로운 이터레이터로 분할한다. 즉, 제너레이터를 사용한 for 문이 여러 개 있는 것과 비슷하다. 이 때 개별 요소의 크기가 크고, 이터러블 여러개를 복사한다고 하면 사용에 유의해야 한다.반복을 여러 번 해야 되는 경우에는 itertools.tee를 사용한다.
중첩 루프
def search_nexted_bad(array, desired_value): coords = None for i, row in enumerate(array): for j, cell in enumerate(row): if cell == desired_value: coords = (i, j) break if coords is not None: break if coords is None: raise ValueError(f"{desired_value} 값을 찾을 수 없음") logger.info("[%i, %i]에서 값 %r 발견", *coords, desired_value) return coords
def _iterate_array2d(array2d): for i, row in enumerate(array2d): for j, cell in enumerate(row): yield (i, j), cell def search_nested(array, desired_value): try: coord = next( coord for (coord, cell) in _iterate_array2d(array) if cell == desired_value ) except StopIteration: raise ValueErro("{desired_value} 값을 찾을 수 없음") logger.info("[%i, %i]에서 값 %r 발견", *coords, desired_value) return coords
제너레이터는 단순 메모리를 절약하기 위한 수단이 아니다. 코드를 더욱 컴팩트하게 만들어 줄 수도 있다.
최대한 중첩 루프를 제거하고 추상화하여 반복을 단순화한다.
파이썬의 이터레이터 패턴
이터레이터를 구현하기 위해서 일반적으로
__iter__
, __next__
를 구현하지만, 엄밀히 말하면 항상 두 가지를 모두 구현해야 하는 것은 아니다. __iter__
를 구현한 이터러블 객체와 __next__
를 구현한 이터레이터 객체를 비교해보자.이터레이션 인터페이스
개념 | 매직 메서드 | 비고 |
이터러블(iterable) | __iter__ | 이터레이터와 함께 반복 로직을 만든다.
이것을 구현한 객체는 for .. in .. 구문을 사용할 수 있다. |
이터레이터(iterator) | __next__ | 한 번에 하나씩 값을 생산하는 로직을 정의한다. 더 이상 생산할 값이 없을 경우는 StopIteration 예외를 발생시킨다. 내장된 next() 함수를 사용해 하나씩 값을 읽어올 수 있다. |
이터러블이 가능한 시퀀스 객체
__iter__
를 구현한 객체는 for 루프를 사용할 수 있다. 하지만 꼭 이러한 형태로만 반복이 가능한 것은 아니다.
파이썬이 for 루프를 만나면 객체가 __iter__
를 구현했는지 확인하고, 없다면 __getitem__
과 __len__
메서드를 구현했는지 확인한다. 즉, 객체가 시퀀스인 경우, 반복이 가능하다. 이 경우, IndexError
예외가 발생할 때까지 순서대로 값을 제공한다.class MappedRange: """특정 숫자 범위에 대해 맵으로 변환""" def __init__(self, transformation, start, end): self._transformation = transformation self._wrapped = range(start, end) def __getitem__(self, index): value = self._wrapped.__getitem__(index) result = self._transformation(value) logger.info("Index %d: %s", index, result) return result def __len__(self): return len(self._wrapped)
>>> mr = MappedRange(abs, -10, 5) >>> mr[0] Index 0: 10 10 >>> mr[-1] Index -1: 4 4 >>> list(mr) Index 0: 10 Index 1: 9 Index 2: 8 ... Index 9: 1 Index 10: 0 Index 11: 1 Index 12: 2 ... Index 14: 4 [10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0, 1, 2, 3, 4]
객체가 시퀀스여서 우연히 반복이 가능할 수 있지만, 기본적으로 반복을 위한 객체를 디자인 할 때는 __iter__ 메서드를 구현하여 정식 이터러블 객체를 만들어야 한다.
코루틴
코루틴의 핵심은 특정 시점에 실행을 일시 중단했다가 나중에 재시작할 수 있는 함수를 만드는 것이다.
파이썬은 코루틴을 생성하기 위해 제너레이터를 활용한다. 제너레이터는 중지 가능한 객체이므로 코루틴이 되기 위한 좋은 성질을 가지고 있다. 하지만 코드의 일부를 일시 중단하는 것으로 충분하지 않고, 그것과 통신하는 수단이 필요하기 때문에(데이터를 전달하거나 컨텍스트가 변경되었음을 알려주기 위해 신호를 보내는 것이 필요) 아래의 메서드가 추가되었다.
제너레이터 인터페이스의 메서드
close
이 메서드가 호출되면 제너레이터에서 GeneratorExit 예외가 발생한다. 따로 처리하지 않으면 제너레이터가 더 이상 값을 생성하지 않고 반복이 중지된다. 즉, 종료를 지정하는데 사용될 수 있다.
def stream_db_records(db_handler): try: while True: yield db_handler.read_n_records(10) except GeneratorExit: db_handler.close()
제너레이터를 호출할 때마다 데이터베이스 핸들러에서 얻은 10개의 레코드를 반환하고, 명시적으로 반복을 끝나기 위해 close()를 호출하면 데이터베이스 연결도 함께 종료한다.
>>> streamer = stream_db_records(DBHandler("testdb")) >>> next(streamer) [(0, 'row 0'), (1, 'row 1'), (2, 'row 2'), (3, 'row 3')....] >>> next(streamer) [(0, 'row 0'), (1, 'row 1'), (2, 'row 2'), (3, 'row 3')....] >>> streamer.close() INFO:....: 'testdb' 데이터 베이스 연결 종료
제너레이터에서 작업을 종료할 때는 close() 메서드를 사용한다.
이 메서드는 리소스를 정리하기 위해 사용하는 것으로, 컨텍스트 관리자를 사용하지 않았거나 자동으로 정리가 어려운 경우에 수동으로 리소스를 해제하기 위해 호출한다.
throw(ex_type[, ex_value[, ex_traceback]])
이 메서드는 제너레이터가 중단된 현재 위치에서 예외를 던진다. 예외를 처리했으면 except 절의 코드가 호출되고, 예외를 처리하지 않았으면 예외가 호출자에게 전파된다.
class CustomException(Exception): """처리하려는 에러 유형""" def stream_data(db_handler): while True: try: yield db_handler.read_n_records(10) except CustomException as e: logger.warning("%r 에러 발생 후 계속 진행", e) except Exception as e: logger.error("%r 에러 발생 후 중단", e) db_handler.close() break
>>> streamer = stream_data(DBHandler("testDB")) >>> next(streamer) [(0, 'row 0'), (1, 'row 1'), (2, 'row 2'), (3, 'row 3')....] >>> next(streamer) [(0, 'row 0'), (1, 'row 1'), (2, 'row 2'), (3, 'row 3')....] >>> streamer.throw(CustomerException) WARNING: CustomException() 에러 발생 후 계속 진행 [(0, 'row 0'), (1, 'row 1'), (2, 'row 2'), (3, 'row 3')....] >>> streamer.throw(RuntimeError) ERROR: RuntimeError() 발생 후 중단 INFO: 'testdb' 데이터베이스 연결 종료 Trackback (most recent call last): .... StopIteration
send(value)
제너레이터의 주요 기능은 고정된 수의 레코드를 읽는 것이다.
next()
는 읽어올 개수를 파라미터로 받을 수 없다. send()
는 파라미터를 사용해 읽어올 개수를 설정할 수 있다.def stream_db_record(db_handler): retrieved_data = None previous_page_size = 10 try: while True: page_size = yield retrieved_data if page_size is None: page_size = previous_page_size previous_page_size = page_size retrieved_data = db_handler.read_n_records(page_size) except GeneratorExit: db_handler.close()
receive = yield produced
yield
는 두가지 역할을 한다.produced
값을 호출자에게 보내고 그 곳을 멈춘다. 호출자는next()
메서드를 호출해 다음 라운드에서 값을 가져올 수 있다.
- 호출자로부터
send()
메서드를 통해 전달된produced
값을 받는 것이다. 이렇게 입력된 값은receive
변수에 할당된다.
코루틴에 값을 전달하는 것은
yield
구문이 멈춘 상태에서만 가능하다. 그러려면 코루틴을 해당 상태까지 이동시켜야 한다. 유일한 방법은 next()
를 호출하는 것이다. 코루틴에게 무언가 요청하기 위해서는 반드시 next()
를 거쳐야 한다는 의미이다.>>> def coro(): y = yield .... .... >>> c = coro() >>> c.send(1) TypeError: can't send non-None Value to a just-started generator
def stream_db_records(db_handler): retrieved_data = None page_size = 10 try: while True: page_size = (yield retrieved_data) or page_size retrieved_data = db_handler.read_n_records(page_size) except GeneratorExit: db_handler.close()
제너레이터에서 처음
next()
를 호출하면 yield
를 포함하는 위치까지 이동한다. 그리고 현재 상태의 변수 값을 반환하고, 거기에 멈춘다. 변수의 초기값은 None
이고, 처음 next()
를 호출하면 None
을 반환한다.여기서 옵션이 2가지가 있다.
next()
를 호출하면 기본값인 10을 반환. 평소처럼 작업이 계속된다. 여기서next()
는send(None)
과 같다.
send(<value>)
를 통해 명시적인 값을 제공하면yield
문의 반환 값으로page_size
의 변수에 설정된다. 기본 값이 아닌 사용자가 지정한 값이 page_size로 설정되고, 해당 크기만큼 데이터 베이스에서 읽어온다. 이어지는 호출에 대해서도 같은 값이 적용된다
코루틴 고급 주제
코루틴은 조금 더 향상된 제너레이터다. 하지만 많은 코루틴을 동시에 사용하는 등의 처리에서 새로운 문제를 맞이한다. 예외 처리는 물론이고 서브 코루틴의 값을 어디에서든 사용하도록 해야 하고, 여러 코루틴을 스케줄링 해야 한다.
코루틴에서 값 반환하기
반복이란
StopIteration
예외가 발생할 때까지 next()
메서드를 계속해서 호출하는 메커니즘이다.위에서는 for 루프의 각 단계에서 생성하는 값에 대해서 알아봤었다.
코루틴은 기술적으로 제너레이터이지만 반복을 염두에 두고 만든 것이 아니라 나중에 코드가 실행될 때까지 코드의 실행을 멈추는 것을 목표로 한다. 코루틴은 반복보다는 상태를 중단하는 것에 초점을 맞추고 있다.
제너레이터가 값을 반환하게 하려면 어떻게 해야 할까? 분명 반복을 중단한 뒤에야 값을 가져올 수 있을 것이다.
값을 반환하면 반복이 즉시 중단된다. 문법의 통일성을 위해
StopIteration
예외는 계속 발생한다. 반환하려는 값는 exception
객체에 저장된다. 해당 값을 처리하는 책임은 호출자에게 있다.>>> def generator(): yield 1 yield 2 yield 3 .... .... >>> value = generator() >>> next(value) 1 >>> next(value) 2 >>> try: next(value) except StopIteration as e: print(f"최종 반환값: {e.value}") 최종 반환값 3
StopIteration
예외를 사용하는 것이 가장 깔끔한 방법은 아니겠지만, 문법적 하위 호환이 되기 때문에 제너레이터의 인터페이스를 바꾸지 않아도 된다는 장점을 가진다.작은 코루틴에 위임하기 - yield from 구문
가장 간단한 yield from 사용 예
가장 간단한 형태의 yield from 구문은 제너레이터 체인에서 살펴볼 수 있다.
def chain(*iterables): for it in iterables: for value in it: yield value
여러 개의 이터러블을 받아 중첩 루프를 사용해 리스트를 튜플과 비교하는 것처럼 직접 비교가 어려운 자료형에 대해서도 한 번에 처리할 수 있어 편리하다.
def chain(*iterables): for it in iterables: yield from it
>>> list(chain("hello", ["world"], ("tuple", "of", "values."))) ["h", "e", "l", "l", "o", "world", "tuple", "of", "values"]
댓글